# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""Utility routines used throughout the 'gvn' wrapper.

   Note that all path manipulations assume Unix-style paths, as expected
   by internal classes.  This means that they can't use os.path methods,
   in case we're actually on Windows!
"""

import codecs
import datetime
import os
import posixpath
import re
import shutil
import sys
import time

from errno import ENOENT
from tempfile import mkstemp

import gvn.errors
import gvn.platform

# XXX We had this in 1.4, but lost it at some point on trunk.
try:
  from svn.core import APR_OS_START_SYSERR
except ImportError:
  APR_OS_START_SYSERR = 720000


VALID_CHANGE_NAME_PAT = r'^[A-Za-z][A-Za-z0-9_-]{,49}$'
VALID_CHANGE_NAME_RE  = re.compile(VALID_CHANGE_NAME_PAT)


class ListDict(dict):
  """Dictionary of lists.

  This is just sugar so you don't have to:

  try:
    d[k].append(v)
  except KeyError:
    d[k] = [v]

  """

  def append(self, key, val):
    if key not in self:
      self[key] = []
    return self[key].append(val)

  def extend(self, key, l):
    if key not in self:
      self[key] = []
    return self[key].extend(l)


class BoolProperty(property):
  def __init__(self, fget=None, fset=None, fdel=None, doc=None):
    if fget is None:
      bfget = fget
    else:
      def bfget(self):
        result = fget(self)
        if result == 'true':
          return True
        return False

    if fset is None:
      bfset = fset
    else:
      def bfset(self, value):
        if value:
          return fset(self, 'true')
        return fset(self, 'false')

    property.__init__(self, bfget, bfset, fdel, doc)


REVISION_PAT = r'^[rR]?([0-9]+)$'
REVISION_RE  = re.compile(REVISION_PAT)

def MatchRevision(word):
  """Match word against an svn revision pattern.

  Returns the revision number (sub)string of word if present
  otherwise None.
  """
  if not word:
    return None

  matchobj = REVISION_RE.match(word)
  if not matchobj:
    return None

  return matchobj.group(1)


def ParseChangeName(change_name):
  """Parse a change_name into useful constituent parts.

  Change names are of the form:

    [user/]branchname[@revision]

  NOTE: Validation of the branchname is *not* done here.

  Returns a tuple of the form (username, branchname, revision).
  Any of these can be None to indicate that no such element could be
  parsed.
  """
  username = None
  branchname = None
  revision = None

  if change_name:
    try:
      idx = change_name.index('/')
    except ValueError:
      idx = None
    if idx is not None:
      username = change_name[:idx]
      change_name = change_name[idx+1:]

    try:
      idx = change_name.rindex('@')
    except ValueError:
      idx = None
    if idx is not None:
      # For some reason this triggers a bug:
      # revision = MatchRevision(change_name[idx+1:])
      revision = change_name[idx+1:]
      if len(revision) > 1 and revision[0] in ['r', 'R']:
        revision = revision[1:]

      # TODO(epg): I'm retaining the behavior where 'changefoo@' ==
      # 'changefoo', but i think that's an error and we should just
      # let the int() constructor raise ValueError.
      if revision == '':
        revision = None
      else:
        # TODO(epg): I've said before that we should raise exceptions
        # when asked to parse an invalid change spec.  But, as long as
        # this function works this way and all callers expect that, we
        # need to catch this.
        try:
          revision = int(revision)
        except ValueError:
          revision = None

      change_name = change_name[:idx]

    branchname = change_name

  if username == '':
    username = None
  if branchname == '':
    branchname = None

  return (username, branchname, revision)


def ValidateChangeName(change_name):
  """Validates that a given change name is acceptable.

  Raises a ChangeBranch exception if any of the following
  conditions are not met:
    1. change_name does not conform to VALID_CHANGE_NAME_PAT
    2. change_name could be mistaken for a revision number
  """
  (username, branchname, revision) = ParseChangeName(change_name)

  if not branchname or not VALID_CHANGE_NAME_RE.match(branchname):
    error = ("'%s' not a valid change name. Must conform to '%s'"
             % (branchname, VALID_CHANGE_NAME_PAT))
    raise gvn.errors.InvalidChangeName(error)

  if MatchRevision(branchname):
    error = "'%s' should not look like a repository revision." % branchname
    raise gvn.errors.InvalidChangeName(error)


def IsValidChangeName(change_name):
  """A convenience function so callers don't have to try/except.
  """
  try:
    ValidateChangeName(change_name)
  except gvn.errors.InvalidChangeName:
    return False

  return True


def ConstructChangeName(username, branchname, revision=None):
  """Return a valid change name identifier.

  If revision is not specified then HEAD is assumed.
  """
  if not username:
    raise gvn.errors.InvalidChangeName('No username specified.')

  change_name = '/'.join([username, branchname])
  if revision is not None:
    change_name = '@'.join([change_name, str(revision)])

  ValidateChangeName(branchname)

  return change_name

def Prompt(prompt_string):
  return raw_input(prompt_string).strip()


def ClimbAndFind(path, test):
  """Work up the directory tree from the given path, calling test(path)
  until test returns either 0 or 1.  If test(X) returns 0, return X.  If
  test(X) returns 1, return the previous directory (one deeper than X).
  Return '/' if the path is exhausted without test() ever returning 0 or 1.

  Examples:
    test(/path/to/foo/bar)  returns -1 => call test(/path/to/foo)
    test(/path/to/foo)      returns -1 => call test(/path/to)
    test(/path/to)          returns 0  => return /path/to
    test(/path/to)          returns 1  => return /path/to/foo
  """
  shallower = deeper = path
  while shallower != '/':
    t = test(shallower)
    if t == 0:
      return shallower
    elif t == 1:
      return deeper
    deeper = shallower
    # We have /-separated paths on all platforms, so we need to use posixpath
    # instead of os.path.
    shallower = posixpath.dirname(shallower)
    # If we were passed an absolute Windows path (C:/foo/bar), we may climb
    # up until we have no '/'left at all.
    if shallower == '':
      return '/'

  return shallower

def ConvertStringDateToDateTime(date):
  """Convert a string date in the svn standard format into a
  datetime.datetime object, to 1-second precision.
  """
  format = '%Y-%m-%dT%H:%M:%S'
  date = re.sub(r'\.\d*Z$', '', date)
  t = time.strptime(date, format)
  return datetime.datetime(t[0], t[1], t[2], t[3], t[4], t[5])

def RelativePath(parent, child):
  """Return the path of child relative to parent.

  If parent is '', simply return child.  Else, if child is not a child
  of parent (e.g. RelativePath('/tmp', '/etc')), raise
  gvn.errors.PathNotChild.  Else, return the relative path
  (e.g. RelativePath('/tmp/wc', '/tmp/wc/lib' => 'lib')).

  Raises:
  gvn.errors.PathNotChild

  """

  if parent == '':
    if child.startswith('/'):
      raise gvn.errors.PathNotChild(child, parent)
    return child

  p = child.split(parent, 1)
  if len(p) == 1 or p[0] != '':
    raise gvn.errors.PathNotChild(child, parent)
  return p[1].lstrip('/')

def IsChild(child, parent):
  """Return True if child is parent (a directory) or is a path under parent."""

  if parent == '':
    return not child.startswith('/')

  return child == parent or child.startswith(parent + '/')

def PathSplit(path, maxsplit=-1, uniform=True):
  """Return list of components of path, like str.split not os.path.split .

  path must be normal form (no doubled or trailing / characters)

  Arguments:
  path     -- path to split
  maxsplit -- do at most maxsplit splits (default full split)
  uniform  -- whether to use / or system-specific separator (default True)
  """

  # Rely on the undocumented behavior that maxsplit=-1 is the same as
  # not specifying maxsplit at all.
  if not uniform and sys.platform == 'win32':
    # Nope, ntpath is just '\\' not '\\/'.
    return path.split('\\/', maxsplit)
  else:
    return path.split('/', maxsplit)

def CommonPrefix(paths, uniform=True):
  """Return the longest common leading component of paths.

  paths must be:
   - all absolute or all relative, not mixed
   - normal form (no doubled or trailing / characters)

  Otherwise the result is undefined.

  You might think we've reinvented os.path.commonprefix .  You'd be wrong:
  >>> os.path.commonprefix(['/tmp/ab', '/tmp/ac'])
  '/tmp/a'

  This brokenness is actually documented in the HTML help, but not in
  the doc string.

  Arguments:
  paths   -- list of paths
  uniform -- whether to use / or system-specific separator (default True)
  """

  if len(paths) == 0:
    return ''
  elif len(paths) == 1:
    return paths[0]

  common = paths[0]
  common_components = PathSplit(common, uniform=uniform)
  for path in paths[1:]:
    if path == common:
      continue

    components = PathSplit(path, uniform=uniform)
    # We need to know whether we matched all components or broke out early.
    partial_match = False
    for (index, component) in enumerate(components):
      try:
        if component != common_components[index]:
          common_components = components[:index]
          #   '/'.join(['', 'a'])   => '/a'
          #   os.path.join('', 'a') => 'a'
          # While we're at it, why does os.path.join not take a list
          # anyway?  Are you trying to minimize consistentcy?
          # And don't worry about uniform=False; Windows works just
          # fine with / as a separator.
          common = '/'.join(common_components)
          partial_match = True
          break
      except IndexError:
        # For e.g. common='/a' path='/a/b' we get here on 'b', so
        # leave '/a' as common.
        partial_match = True
        break
    if not partial_match and len(common_components) > len(components):
      # Got all the way through this path without hitting a different
      # component, and this path is shorter than our current guess on
      # common; that means this is more common.
      common_components = components
      common = path

  return common


# XXX This really should be bound.  Maybe in ctypes?
def APR_TO_OS_ERROR(e):
  if e == 0:
    return 0
  return e - APR_OS_START_SYSERR


class Editor(object):
  """Abstraction for exchanging information with a user via forms in a
  text editor.

  Callers should not call Done unless the user's action has been
  completed successfully.  If anything has gone wrong, callers should
  not call Done, instead informing the user that the form remains
  saved at the path in the tmpfile member.
  """

  def __init__(self, executable):
    self._executable = executable
    self.tmpfile = None

  def Edit(self, text, encoding, tmp_prefix='gvn.', _system=os.system):
    """Return user-edited text, or None if user aborted.

    Arguments:
    text                -- text for the user to edit
    encoding            -- user's encoding, for encoding text in the file
    tmp_prefix          -- prefix to use on the temporary filename
    _system             -- for testing, ignore
    """
    # Some editors on Windows (at least notepad) prepend a BOM.
    do_bom = gvn.platform.EditorWantsBOM(self._executable, encoding)

    # Create a temporary file.
    (fd, self.tmpfile) = mkstemp(suffix='.txt', prefix=tmp_prefix, text=True)
    f = os.fdopen(fd, 'w')
    if do_bom:
      f.write(codecs.BOM_UTF8)
    f.write(text.encode(encoding))
    f.close()
    cmd = ' '.join((self._executable, self.tmpfile))
    status = _system(cmd)

    # If the editor failed, return None for abort.
    if status != 0:
      raise gvn.errors.Editor(cmd, status)

    # If the file does not exist or is empty, abort.
    try:
      post_edit_stat = os.stat(self.tmpfile)
    except OSError, e:
      if e.errno != ENOENT:
        raise
      # User removed tmpfile; don't let callers think it's still there.
      self.tmpfile = None
      post_edit_stat = None
    if post_edit_stat is None or post_edit_stat.st_size == 0:
      return None

    f = codecs.open(self.tmpfile, 'r', encoding=encoding)
    text = f.read()
    if do_bom:
      text = text.lstrip(codecs.BOM_UTF8.decode(encoding))
    f.close()

    # If the file contained nothing but the BOM, abort.
    if len(text) == 0:
      return None

    return text

  def IsDone(self):
    """Return False if Editor.Done should be called."""
    return self.tmpfile is None

  def Done(self):
    """Remove the tmpfile, marking this Editor as done."""
    if self.IsDone():
      return

    try:
      os.unlink(self.tmpfile)
    except:
      # It's a temp file, who cares.
      pass
    self.tmpfile = None

def isatty(fp):
  try:
    return os.isatty(fp.fileno())
  # AttributeError for file-like objects with no fileno method
  # EnvironmentError for errors from fileno
  except (AttributeError, EnvironmentError):
    return False


def ParseOwners(owners_contents):
  """Return (users, groups, noparent) parse from owners_contents.

  Arguments:
  owners_contents       -- OWNERS-type text (must have splitlines method)

  Returns:
  tuple of (set(), set(), bool())

  Format for OWNERS::

      # takes comments
      # This special line means we don't inherit OWNERS from higher dirs
      set noparent
      # single user names, one per line, as such:
      user
      # or groups like so:
      group: svngroup1  # and trailing comments are allowed
      # include files not supported, nor planned
      file:../some_other_dir/OWNERS
  """
  users = set()
  groups = set()
  noparent = False

  for line in owners_contents.splitlines():
    # Strip comments and whitespace and skip blank/only-comments lines.
    line = re.sub('#.*', '', line).strip()
    if len(line) == 0:
      continue

    if re.match('set\s+noparent', line):
      noparent = True
      continue

    m = re.match('group:\s*(.*)', line)
    if m is not None:
      groups.add(m.group(1))
      continue

    m = re.match('file:\s*(.*)', line)
    if m is not None:
      # The OWNERS system Google implemented for p4 allowed includes,
      # but we think groups are a better solution.
      # For now, we'll see if users can do without it.
      continue

    # TODO(epg): Behave like Marc's original code for now.  It ignored
    # lines with embedded whitespace, but I don't see why.  For
    # organizations that have no spaces in usernames, treating such
    # lines as usernames has the same effect as ignoring them; for
    # organizations that have spaces in usernames (which svn is cool
    # with), allowing this is a big win.
    if re.search('\s+', line):
      continue

    users.add(line)
  return (users, groups, noparent)
